Skip to content

feat(stellar): Initial version of Stellar Lazer contracts#3612

Open
jayantk wants to merge 23 commits intomainfrom
stellar
Open

feat(stellar): Initial version of Stellar Lazer contracts#3612
jayantk wants to merge 23 commits intomainfrom
stellar

Conversation

@jayantk
Copy link
Copy Markdown
Contributor

@jayantk jayantk commented Apr 16, 2026

Summary

Initial version of Stellar lazer contracts. There are still a few issues here that need to be resolved before we can ship (see inline fixmes / todos), but i would like to get something merged to main that I can work off of.

Rationale

We want this for our Stellar deployment.

How has this been tested?

  • Current tests cover my changes
  • Added new tests
  • Manually tested the code

Open with Devin

jayantk and others added 15 commits March 27, 2026 19:48
Set up the lazer/contracts/stellar/ Cargo workspace with two crates:
- wormhole-executor-stellar: implements Wormhole VAA binary parsing with
  error types covering invalid version, truncated data, and malformed sigs
- pyth-lazer-stellar: skeleton crate for the Pyth Lazer verification contract

Includes 14 unit tests for VAA parsing covering edge cases, multi-signature
VAAs, and governance-style payloads. Both crates build for wasm32-unknown-unknown.

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
)

* feat: implement Pyth Lazer core verification contract for Stellar

Implement the pyth-lazer-stellar Soroban contract with LE-ECDSA signed
price update verification. The contract recovers signer public keys via
secp256k1_recover + keccak256, compresses them to SEC-1 33-byte format,
and validates against a trusted signers map with expiry timestamps.

Includes:
- error.rs: ContractError enum for all error cases
- state.rs: Storage keys, trusted signer management, TTL extension helpers
- verify.rs: LE-ECDSA envelope parsing, key recovery, point compression
- lib.rs: Contract entry points (initialize, verify_update, update_trusted_signer, upgrade)
- test.rs: 9 unit tests using Sui test vectors (success, invalid magic,
  truncated data, unknown signer, expired signer, multiple signers, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update Cargo.lock and add test snapshots for pyth-lazer-stellar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove autogenerated test snapshot JSON files and add .gitignore

Soroban test snapshots are auto-generated during test runs and should
not be checked in. Added .gitignore to exclude test_snapshots/ dirs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…#3581)

Add 3 tests using real mainnet guardian set upgrade VAAs (from TON test
utils) to validate the Stellar Wormhole VAA parser against production
data. Each test verifies guardian_set_index, signature count, emitter
chain/address, sequence, payload structure (Core module + action), and
the new guardian set index in the upgrade payload.

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…tor (#3578)

Implement guardian signature verification using secp256k1_recover and
keccak256, Ethereum address derivation, quorum checking (2/3+1),
guardian set storage with TTL management, contract initialization,
and guardian set upgrade via governance VAAs.

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…llar (#3579)

Add Unauthorized error variant to ContractError and comprehensive governance
tests covering: add/update/remove trusted signers, unauthorized caller
rejection, and upgrade function executor authorization.

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…3582)

Implement governance.rs module with PTGM header parsing (magic, module,
action, target_chain_id), action-specific payload parsing for
update_trusted_signer and upgrade actions, and cross-contract dispatch.

Add execute_governance_action() to lib.rs with VAA verification, emitter
validation, replay protection via strictly increasing sequence numbers,
and cross-contract calls to target contracts.

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Update hydra CLI to v0.9.0 in Dockerfile.metis (#3575)

Rename metis CLI binary to hydra and pin to v0.9.0 release:
- Download URL: latest/metis-x86_64-unknown-linux-gnu -> v0.9.0/hydra-x86_64-unknown-linux-gnu
- Install path: /usr/local/bin/metis -> /usr/local/bin/hydra
- Comment: metis CLI -> hydra CLI

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat: implement payload parsing module for Pyth Lazer Stellar contract

Adds payload.rs module that decodes verified Pyth Lazer payload bytes
into structured Rust types (Update, Feed, Channel, MarketSession).
Parses all 13 property types matching the Sui reference implementation,
with correct LE two's complement handling for signed integers and
exists-flag handling for optional properties. Includes 9 unit tests
using shared test vectors (BTC/USD, ETH/USD, SOL/USD feeds).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Copy the design document from the Hydra document store into the
repository so it lives alongside the contract code.

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…racts (#3584)

Fix the pyth-lazer-stellar WASM build by gating the payload module
(which uses alloc::vec::Vec) to only compile on non-wasm32 targets
and in tests. Add a deployment script that automates building,
optimizing, deploying, and initializing both contracts on the
Stellar network. Add wasm-opt optimize target to Makefile.

Testnet deployment verified:
- Executor: CCO3TSB5KEAQRAWFTHB2U7KBE4GNX6PXHZCKOF6JMBBE4GXT3G2QROCU
- Lazer: CCU4CZWTQKWG6462X6MDAJR245K2XB4LSUKEACRAHYMSLYENQ6MXKKHE
- Both initialized with testnet guardian set
- verify_update correctly returns SignerNotTrusted (no trusted signer
  added yet via governance VAA)

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…llar contracts (#3583)

Add cross-contract integration tests exercising the full governance and
verification flows between wormhole-executor-stellar and pyth-lazer-stellar.
Create README with architecture overview, build/test/deploy instructions,
and enhance Makefile with fmt/clippy/check targets.

Integration tests cover:
- Full governance flow (VAA -> executor -> Lazer contract)
- Full verification flow (signed update -> verify -> parse payload)
- Upgrade governance dispatch
- Guardian set upgrades followed by governance
- Negative cases (expired signer, wrong emitter, replay, unauthorized)

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
… contract (#3586)

Remove the #[cfg(any(test, not(target_arch = "wasm32")))] attribute from
the payload module declaration and enable the soroban-sdk "alloc" feature
to provide a global allocator for wasm32 builds, allowing alloc::vec::Vec
usage in the payload parsing code.

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
)

* feat: add end-to-end test script for Pyth Lazer Stellar contracts

Add a self-contained bash script that tests the full Lazer price verification
flow on Stellar testnet using a real signed update from the Pyth Lazer service.

The script fetches a live price update, recovers the signer's public key via
ECDSA recovery, deploys a fresh Lazer contract, adds the signer as trusted,
and calls verify_update to confirm the real payload verifies successfully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: rewrite E2E test as proper TypeScript project following repo conventions

Replace the inline bash/Node.js script with a proper TypeScript project
at lazer/contracts/stellar/scripts/e2e/ with package.json, tsconfig.json,
and turbo.json following the pattern of lazer/contracts/sui/sdk/js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: simplify E2E test and add signer to deploy script per review feedback

- deploy.sh now adds the Pyth Lazer trusted signer (hardcoded pubkey)
  during deployment, so contracts are ready to use immediately
- E2E test simplified to only test verify_update against an existing
  contract address (passed via --contract-id), no redeployment
- Removed @noble/curves and @noble/hashes dependencies (no longer
  needed since pubkey recovery moved to deploy script)
- Deployed initialized testnet contract and documented address in README
- Verified E2E test passes against the new testnet deployment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…deploy (#3588)

* feat: enhance Stellar Lazer e2e test with output validation and auto-deploy

- Modify Lazer contract `initialize` to accept optional initial trusted signer,
  fixing deploy.sh which couldn't call `update_trusted_signer` (requires executor
  contract auth that can't be provided from CLI)
- Update deploy.sh to pass initial signer during initialization and build only
  needed packages (fixes wasm32 build failure on integration-tests crate)
- Rewrite e2e test to deploy fresh contracts via deploy.sh instead of requiring
  pre-existing --contract-id
- Add payload parsing and validation: verify magic number, parse timestamp,
  channel, feed IDs, and price data
- Add transaction hash capture from Horizon API with Stellar Explorer link
- Force transaction submission with --send=yes for on-chain verification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: separate deployment from e2e test, require --contract-id

The e2e test no longer deploys contracts inline. Instead, it requires
a --contract-id flag pointing to a pre-deployed Lazer contract.
Deploy separately using deploy.sh first, then run the test against it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Hydra Worker <hydra-worker@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@jayantk jayantk requested a review from a team as a code owner April 16, 2026 18:09
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api-reference Ready Ready Preview, Comment May 1, 2026 7:11pm
component-library Ready Ready Preview, Comment May 1, 2026 7:11pm
developer-hub Error Error May 1, 2026 7:11pm
entropy-explorer Error Error May 1, 2026 7:11pm
insights Error Error May 1, 2026 7:11pm
proposals Ready Ready Preview, Comment May 1, 2026 7:11pm
staking Ready Ready Preview, Comment May 1, 2026 7:11pm

Request Review

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 new potential issues.

View 9 additional findings in Devin Review.

Open in Devin Review

Comment thread lazer/contracts/stellar/scripts/deploy.sh Outdated
Comment on lines +66 to +68
/// FIXME: the handling of the guardian sets here is wrong. It needs to expire the current guardian set
/// and update to the next guardian set, creating the 24h period where both guardian sets are accepted.
/// Look at the ethereum wormhole contract implementation for a guide to how this should work.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Guardian set upgrade lacks transition period (acknowledged FIXME)

The update_guardian_set function (lib.rs:69-147) immediately replaces the current guardian set with the new one. The standard Wormhole behavior (as implemented in the EVM core bridge) includes a ~24h transition period where both old and new guardian sets are accepted. This is explicitly acknowledged with a FIXME comment at lib.rs:66-68, but it's a significant deviation from the Wormhole protocol that could cause issues during guardian transitions — any pending governance VAAs signed by the old set become immediately invalid after the upgrade.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +221 to +222
// TODO: this contract needs upgradability
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Wormhole executor contract has no upgrade mechanism

The WormholeExecutor contract has a TODO comment at lib.rs:221 noting it needs upgradability, but no upgrade function is implemented. The Lazer contract has an upgrade function callable by the executor, but the executor itself has no way to be upgraded. If a bug is found in the executor's VAA verification or governance dispatch logic, there's no on-chain mechanism to fix it — the entire system would need to be redeployed with new contract IDs and re-initialized.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread lazer/contracts/stellar/contracts/integration-tests/src/test.rs Outdated
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

View 11 additional findings in Devin Review.

Open in Devin Review

Comment on lines +35 to +37
if let (Some(pubkey), Some(expires_at)) = (initial_signer, initial_signer_expires_at) {
state::set_trusted_signer(&env, &pubkey, expires_at);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Silent failure in initialize when only one of the two optional initial-signer params is provided

When initial_signer is Some(key) but initial_signer_expires_at is None (or vice versa), the if let (Some(pubkey), Some(expires_at)) pattern silently skips adding the signer, and initialize returns Ok(()). Since initialize can only be called once (the AlreadyInitialized guard at line 31), the contract is permanently left without the intended initial signer. The only recourse would be to add the signer via a Wormhole governance VAA through the executor, which may not be available immediately after deployment.

Example of the mismatched call

A deployer accidentally calls:

initialize(executor, Some(pubkey), None)

The executor is stored, Ok(()) is returned, but no signer is added. Re-calling initialize to fix it returns AlreadyInitialized. All verify_update calls will fail with SignerNotTrusted until a governance VAA is processed.

Prompt for agents
In PythLazerContract::initialize (lib.rs:25-39), the two optional parameters initial_signer and initial_signer_expires_at are independently optional, but the function only adds the signer when BOTH are Some. If only one is provided, the signer is silently not added and initialize cannot be called again due to the AlreadyInitialized guard.

Consider one of these approaches:
1. Return an error if exactly one of the two options is Some (invalid input).
2. Change the API to use a single Option<(BytesN<33>, u64)> tuple parameter so both values are always provided together or neither is.
3. At minimum, add a comment documenting that both must be Some for the signer to be added.

Option 2 is the cleanest solution as it makes the mismatched case unrepresentable at the type level.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +58 to +63
/// Extend TTL on instance storage (call on every user-facing invocation).
pub fn extend_instance_ttl(env: &Env) {
env.storage()
.instance()
.extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO);
}
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 No contract WASM code TTL extension — only instance storage TTL is extended

The DESIGN.md at lines 312-313 states both contracts should extend their instance AND code TTLs proactively. The implementation extends instance TTL via env.storage().instance().extend_ttl() in both contracts, and persistent entry TTL for guardian set/signer data. However, the WASM code entry has a separate TTL that is never explicitly extended. If the WASM code entry expires and is archived, contract invocations would fail until the code is restored (Protocol 23 auto-restore would handle this but at extra cost to the caller). For long-lived production contracts, consider adding env.deployer().extend_ttl() or using external CLI-based TTL extension for the code entry.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread lazer/contracts/stellar/contracts/pyth-lazer-stellar/src/lib.rs Outdated
Copy link
Copy Markdown
Collaborator

@ali-behjati ali-behjati left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall it looks good but i have two points:

  • let's move this off the pyth-crosschain repo to lazer, then you can rely on many types
  • parsing is done very poorly (it's very error prone this way), is there any reason why we are not using a parsing library like nom or just serde? are there restrictions on wasm?

Comment thread lazer/contracts/stellar/contracts/pyth-lazer-stellar/src/lib.rs Outdated
env.storage().instance().set(&DataKey::Executor, executor);
}

/// Read the executor address. Panics if not initialized.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

panicing is not great [we sure the env handles the panics properly?]. Can't it return an option?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

panicking just reverts the transaction, so this is actually fine. but agree it's better to be more explicit with the errors, so i've fixed this.

Comment on lines +3 to +6
/// TTL threshold: extend when TTL drops below this (approx 6 days at 5s/ledger).
pub const TTL_THRESHOLD: u32 = 100_000;
/// TTL extension target (approx 29 days at 5s/ledger).
pub const TTL_EXTEND_TO: u32 = 500_000;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are these TTLs? Are these costs for storage that we pay? updating every week seems aggressive. can we change it to every year?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stellar has a TTL model where storage on-chain gets archived after a certain amount of time. there are different types of storage with different semantics, but for the storage that we are using, the archiving is temporary. Basically, the info gets moved off chain, and then the next transaction will restore it on-chain. I think there's some added transaction cost for doing that, but there isn't a correctness issue.

also to be clear, this says i'll update the TTL to be 1 month every time it's less than 6 days. We can change around the limits, but there's some question of how much it costs to increment etc that we will have to calibrate in practice.

https://developers.stellar.org/docs/learn/fundamentals/contract-development/storage/state-archival#instance

/// Input: 0x04 || x (32 bytes) || y (32 bytes)
/// Output: parity_byte || x (32 bytes)
/// where parity_byte = 0x02 if y is even, 0x03 if y is odd
fn compress_pubkey(env: &Env, uncompressed: &BytesN<65>) -> BytesN<33> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there should be a function out there for this. are we under contract size limitations here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there isn't a library function for this unfortunately.

Comment on lines +100 to +105
fn read_le_u32(data: &Bytes, offset: u32) -> u32 {
(data.get(offset).unwrap() as u32)
| ((data.get(offset + 1).unwrap() as u32) << 8)
| ((data.get(offset + 2).unwrap() as u32) << 16)
| ((data.get(offset + 3).unwrap() as u32) << 24)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These unwraps are dangerous

Comment on lines +136 to +143
let sequence = ((get_byte(data, seq_offset) as u64) << 56)
| ((get_byte(data, seq_offset + 1) as u64) << 48)
| ((get_byte(data, seq_offset + 2) as u64) << 40)
| ((get_byte(data, seq_offset + 3) as u64) << 32)
| ((get_byte(data, seq_offset + 4) as u64) << 24)
| ((get_byte(data, seq_offset + 5) as u64) << 16)
| ((get_byte(data, seq_offset + 6) as u64) << 8)
| (get_byte(data, seq_offset + 7) as u64);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is strange.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

? if you're referring to having both the parsed body and the bytes, you need the bytes for the verification method. it's a bit awkward

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 11 additional findings in Devin Review.

Open in Devin Review


use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env};

mod bytes;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Both contracts reference missing bytes module — PR appears incomplete

Both pyth-lazer-stellar/src/lib.rs:5 (mod bytes;) and wormhole-executor-stellar/src/lib.rs:2 (pub mod bytes;) declare a bytes submodule, and the code throughout (verify.rs, payload.rs, vaa.rs, governance.rs) imports from it (get_byte, get_byte_n, read_be_u16, read_be_u32, read_be_u64, or_truncated). However, the corresponding bytes.rs files do not exist on disk in either contract's src/ directory and are not present in the PR diff. This means the contracts cannot compile. The PR may be missing these files, or the diff provided is incomplete.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants